面向对象程序设计基于三个基本概念:数据抽象、继承和动态绑定。

15.1 OOP:概述

面向对象程序设计的核心思想:

  • 数据抽象:可以将类的接口与实现分离
  • 继承:可以定义相似的类型并对其相似关系建模
  • 动态绑定:可以在一定程度上忽略相似的类型,以统一的方式使用它们的对象

通过继承关系联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类,其它类则直接或间接从基类继承而来,这些继承的类称为派生类。

基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。

在C++语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明为虚函数

派生类必须在其内部对所有重新定义的虚函数进行声明。派生类可以在这样的函数之前加上virtual关键字,但是并不是非得这么做。

C++11新标准允许派生类显示地注他将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表之后增加一个override关键字

派生类必须通过使用类派生列表明确指出从哪几个基类继承而来的,形式是:首先一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符。

在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定,动态绑定又称为运行时绑定。

15.2 定义基类和派生类

15.2.1 定义基类

基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。

派生类可以继承基类的成员,但如果遇到类型相关的操作时(基类声明virtual),派生类必须对其重新定义,即派生类需要对这些操作提供自己的新定义以覆盖(override)从基类继承而来的旧定义。

C++语言中,基类必须将它的两种成员函数区分开来:

  1. 基类希望派生类进行覆盖的函数,通过将其定义为虚函数,使函数执行时动态绑定
  2. 基类希望派生类直接继承而不要改变的函数

任何构造函数之外的非静态函数都可以是虚函数,关键字virtual只能出现在类内部的声明语句之前,不能用于类外部的函数定义。

如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。

成员函数如果没有被声明成虚函数,则其解析过程发生在编译时而非运行时。

派生类可以继承定义在基类中的成员,但是不一定有权访问:

  • 能访问公有成员及protected成员
  • 不能访问私有成员

15.2.2 定义派生类

派生类必须通过使用类派生列表明确指出从哪个(哪些)基类继承而来。

派生类必须对继承而来的成员函数中需要覆盖的那些重新声明。

派生类经常(但不总是)覆盖它继承的虚函数,如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员函数,派生类会直接继承其在基类中的版本。

派生类可以在它覆盖的函数前使用 virtual 关键字,但不是非得这么做。

C++11新标准允许派生类显示地注明它使用某个成员函数覆盖了它继承的虚函数,具体做法是在形参列表后面、或者 const 成员函数的const 关键字后面、或者是在引用成员函数的引用限定符后面添加一个关键字 override。

C++标准并没有明确规定派生类的对象在内存中如何分布,一般可以理解成连续分布。

**因为在派生类对象中含有于其基类对应的部分,所以能够把派生类对象当成基类来使用,也能将基类的指针或引用绑定到派生类对象中的基类部分上,这种转换通常称为派生类到基类的类型转换,和其他类型转换一样,编译器会隐式地执行派生类到基类的转换。**这种隐式转换意味着:

  • 可以把派生类对象或者派生类对象的引用用在需要基类引用的地方
  • 可以把派生类对象的指针用在需要基类指针的地方

派生类必须使用基类的构造函数来初始化从基类继承而来的成员,即每个类控制它自己的成员初始化过程。

每个类负责定义各自的接口,要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类也是如此。因此,派生类不能直接初始化基类的成员,尽管语法上行得通,但是最好不要,和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过构造函数初始化基类中继承的成员。

派生类的作用域嵌套在基类的作用域之内。

如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。

派生类的声明与其他类差别不大,声明中包含类名但不包含派生列表:

class Bulk_quote : public Quote; //error
class Bulk_quote;				//right

如果某个类用作基类,则该类必须已经定义而非仅仅声明,原因是:派生类包含可以使用它从基类继承而来的成员,为了使用这些成员,派生类当然要知道它们是什么。该规定还有一层隐含的意思:即一个类不能派生它本身。

一个类可以是基类,同时也可以是派生类。

class Base {};
class D1 : public Base {};
class D2 : public D1 {};

Base是D1的直接基类,同时是D2的间接基类。每个类都会继承直接基类的所有成员,该直接基类的成员又含有其基类的成员,因此最终的派生类将包含直接基类的子对象和每个间接基类的子对象。

C++11新标准提供了一种防止继承发生的方法:在类名后跟一个关键字final。

15.2.3 类型转换与继承

理解基类和派生类之间的类型转换是理解C++语言面向对象编程的关键所在。

当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型与该表达式表示对象的动态类型区分开来:

  • 静态类型:编译时已知的,它是变量声明时的类型或表达式生成的类型
  • 动态类型:变量或表达式表示的内存中的对象的类型,直到运行时才可知
//print_total函数中,item形参类型为Quote&
double ret = item.net_price(n);
//item的静态类型是Quote&,动态类型依赖于item绑定的实参
//如果传递一个 Bulk_quote对象给print_total,则item的实参类型为Bulk_quote
//则item的静态类型与动态类型不一致

如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。

不存在从基类向派生类的隐式类型转换:之所以存在从派生类向基类的类型转换是因为每个派生类都包含一个基类部分,而基类的引用或指针可以绑定到该基类部分上。一个基类的对象既可以以独立的形式存在,也可以作为派生类对象的一部分存在。如果基类对象不是派生类对象的一部分,则它只含有基类定义的成员,而不包含派生类定义的成员。因为一个基类的对象可能是派生类对象的一部分,也可能不是,所以不存在从基类向派生类的自动类型转换。

即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换。编译器在编译时无法确定转换是否安全。

派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。

因为拷贝构造函数和赋值运算符接收的参数是类类型的const版本的引用,所以派生类向基类的转换允许我们给基类的拷贝/移动操作传递一个派生类的对象,这些操作不是虚函数,实际运行的函数也是基类的。当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。

15.3 虚函数

在C++语言中,当我们使用基类的引用或指针调用一个虚成员函数时会执行动态绑定。因为直到运行时才能知道到底调用了哪个版本的虚函数,所以所有虚函数都必须有定义。

当某个虚函数通过指针或引用调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本的函数,被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的哪一个。

OPP的核心思想是多态性,引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。

当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。

当在派生类中覆盖基类的某个虚函数时,可以再一次使用virtual,然而这样做并非必须,基类中的虚函数在派生类中隐含地也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。

派生类中虚函数的返回类型也必须与基类函数匹配,唯一的例外是:当基类的虚函数返回类型是类本身的指针或引用时。

虚函数可以拥有默认实参,如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定,即通过基类的引用或指针调用函数,则使用的是基类中定义默认实参,即使实际运行的是派生类中的函数版本也是如此。

如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。

回避虚函数的机制:有些情况不希望进行动态绑定,而是强迫其执行虚函数的特定版本,使用作用域运算符实现:

double undiscounted = baseP -> Quote::net_price(42);//编译时完成解析

通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数机制。通常是一个派生类的虚函数调用它覆盖的基类的虚函数版本时,在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。(如果没有使用作用域运算符,则在运行时解析成派生类自身的调用,导致无限递归)

15.4 抽象基类

纯虚函数无需定义,纯虚函数的声明只能出现在类内部。对于定义了纯虚函数的类,我们不能直接定义这个类的对象,而是通过这个类进行派生使用。

我们可以为纯虚函数提供定义,不过函数体必须定义在类的外部,即不能在类的内部为一个=0的函数提供函数体

含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类,抽象基类负责定义接口,而后续的其他类可以覆盖该接口,不能(直接)创建一个抽象基类的对象。

派生类构造函数只初始化它的直接基类,由直接基类的构造函数初始化间接基类,依此递归。

重构:负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类。

15.5 访问控制权限与继承

每个类分别控制自己的成员初始化过程,每个类还分别控制着其成员对于派生类来说是否可访问。

受保护的成员:

  • 和私有成员类似,受保护的成员对于类的用户来说是不可访问的
  • 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的
  • 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员
    • 派生类的成员和友元只能访问派生类对象中的基类部分和受保护成员
    • 对于普通的基类对象中的成员不具有特殊的访问权限

某个类继承而来的成员的访问权限受到两个因素的影响:

  • 在基类中该成员的访问说明符
  • 派生类的派生列表中的访问说明符
    • 派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响,对基类成员的访问权限只与基类中的访问说明符有关
    • 派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限
类成员/继承方式public继承protected继承private继承
基类的public成员派生类的public成员派生类的protected成员派生类的private成员
基类的protected成员派生类的protected成员派生类的protected成员派生类的private成员
基类的private成员不可见,只能通过基类接口访问不可见,只能通过基类接口访问不可见,只能通过基类接口访问

派生类向基类转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定D继承自B:

  • 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护的或私有的,则用户代码不能使用该转换
  • 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型住那还对于派生类的成员和友元来说永远是可访问的
  • 如果D继承B的方式是公有的活受保护的,则D的派生类的成员和友元可以使用D向B的类型转换;反之,如果D继承B的方式是私有的,则不能使用

友元关系不能传递,也不能继承。基类的友元在访问派生类成员时不具有特殊性,派生类的友元也不能随意访问基类的成员。友元能够访问类对象的成员,这种可访问性包括了类对象内嵌在其派生类对象中的情况。

当一个类将另一个类声明为友元时,这种友元关系只对做出声明的类有效,友元的基类或者派生类不具有特殊的访问能力。

通过在类内部使用using声明语句,我们可以改变个别成员的可访问性。派生类只能为那些它可以访问的名字提供using声明。

struct和class关键字定义的类具有不同的默认访问说明符,也具有不同的默认派生运算符:(唯一差别)

  • struct:默认访问是public,默认派生也是public
  • class:默认访问是private,默认派生也是private

一个私有派生的类最好显示地将private声明出来,而不要仅仅依赖于默认的设置。

15.6 继承中的类作用域

每个类定义自己的作用域,存在继承关系时,派生类的作用域嵌套在基类的作用域之内。如果一个名字在派生类作用域内无法正常解析,则编译器将继续在外层基类作用域中寻找该名字的定义。

在编译时进行名字查找,因此即使静态类型与动态类型可能不一致,能使用哪些成员仍然是由静态类型决定的。例如,通过基类指针指向一个派生类,则名字查找时是从基类的作用域开始查找的,无法查找到派生类特有的成员。

和其他作用域一样,派生类也能宠用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字。派生类的成员将隐藏同名的基类成员。可以通过作用域运算符来使用隐藏的成员。

除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。

声明在内层作用域的函数并不会重载声明在外层作用域的函数,因为名字查找先于类型检查,当编译器在内层作用域找到对应名字后便不再去外层作用域查找。

因此,基类与派生类中的虚函数必须有相同的形参列表,反之无法通过基类的引用或指针调用派生类的虚函数。只有调用虚函数时才会发生动态绑定(由动态类型决定),普通函数发生静态绑定。

覆盖重载的函数

和其他函数一样,成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的0个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。

有时一个类仅需要覆盖重载集合中的一些而非全部函数,此时不得不覆盖基类中的全部版本,现任操作及其烦琐。

一种好的解决方案是为重载的成员提供一条using声明语句,这样就无须覆盖基类中的每一个重载版本了。using声明语句指定一个名字而不指定形参列表,此时派生类只需要定义其特有的函数就可以了。

类内using声明的一般规则同样适用于重载函数的名字:基类函数的每个实例在派生类中都是可访问的。对派生类没有重新定义的重载版本的访问实际是对using声明点的访问。

15.7 构造函数与拷贝控制

15.7.1 虚析构函数

如果基类的析构函数不是虚函数,则delete一个执行派生类对象的基类指针将产生未定义行为。

之前介绍过一条经验准则:如果一个类需要析构函数,那么它也同样需要拷贝和复制操作。

基类的析构函数并不遵循上述准则,它是一个重要的例外。一个基类总是需要析构函数,而且它能够将析构函数设定为虚函数,此时该析构函数为了成为虚函数而令内容为空,显然无法由此推断该基类还需要赋值运算符或拷贝构造函数。

基类需要一个虚析构函数这一事实还会对基类和派生类的定义产生另一个间接的影响:如果一个类定义了析构函数,即使它通过=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作。

15.7.2 合成拷贝控制与继承

基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似:它们对类本身的成员依次进行初始化、赋值或销毁的操作。此外,这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。基类成员无论是合成的版本还是自定义的版本都没有太大影响,唯一的要求是相应的成员应该可访问并且不是一个被删除的函数。

因为定义了析构函数而不能拥有合成的移动操作,因此当我们移动此类的对象时实际使用的是合成的拷贝操作。

和任何类的情况一样,基类或派生类也能出于同样的原因将其合成的默认构造函数或任何一个拷贝控制成员定义成被删除的函数。此外,定义基类的方式可能导致有的派生类成员成为被删除的函数:

  • 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作
  • 如果在基类中有一个不可访问或删除的析构函数,则派生类中合成的默认和拷贝构造函数将是删除的,因为编译器无法销毁派生类对象的基类部分
  • 编译器不会合成一个删除掉的移动操作。当我们使用=default请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的

大多数基类都会定义一个虚析构函数,因此在默认情况下,基类通常不含有合成的移动操作,从而派生类也没有合成的移动操作。可以显示移动操作,一旦如此,那必须同时显示定义拷贝操作。

15.7.3 派生类的拷贝控制成员

派生类的构造函数在其初始化阶段不但要初始化派生类自己的成员,还负责初始化派生类对象的基类部分,因此,派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员,派生类赋值运算符也必须为其基类部分的成员赋值。

与构造函数及赋值运算符不同,析构函数只负责销毁派生类自己分配的资源。因为对象的成员是被隐式销毁的,派生类的基类部分也是自动销毁的。

在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们向拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显示地使用基类的拷贝(或移动)构造函数。

class Base {}
class D: public Base {
    D(const D& d): Base(d){}
    D(D&& d): Base(std::move(d)){}
    
    //D这个拷贝构造函数很可能是不正确的定义
    //基类部分被默认初始化,而非拷贝
    //Base的默认构造函数被用来初始化D对象的基类部分
    //D成员的值从其他对象拷贝得来
    D(const D& d){}
}

与拷贝和移动构造函数一样,派生类的赋值运算符也必须显示地为其基类部分赋值:

D& D::operator=(const D& rhs){
    Base::operator=(rhs);	//为基类部分赋值
    //为派生类成员赋值
    //......
    return *this;
}

和构造函数及赋值运算符不同的是,派生类的析构函数只负责销毁由派生类自己分配的资源。

如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。

派生类对象创建时,首先是其基类部分首先被构建,当执行基类的构造函数时,对象的派生类部分是未被初始化的状态,类似的,当执行基类的析构函数时,派生类部分已经被销毁掉了。由此可知,当执行上述基类成员时,对象处于未完成的状态。

为了能够正确地处理这种未完成状态,编译器认为对象的类型在构造或析构的过程中仿佛发生了变化一样。也就是说,当我们构建一个对象时,需要把对象的类和构造函数的类看作是同一个,对虚函数的调用和绑定正好符合这种把对象的类和构造函数的类看成同一个的要求;对于析构函数也是同样的道理。上述的绑定不仅对直接调用虚函数有效,对间接调用也是有效的,这里的间接调用指通过构造函数(或析构函数)调用另一个函数。

15.7.4 继承的构造函数

在C++11新标准中,派生类能够重用其直接基类定义的构造函数。一个类只初始化它的直接基类,一个类也只能继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。

派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的using声明语句。using声明语句只是令某个名字在当前作用域内可见,而当作用域构造函数时,using声明语句将令编译器产生代码。

对于基类的每个构造函数,编译器都生成一个与之对应(形参列表完全相同)的派生类构造函数,如果派生类有自己的数据成员,则这些成员将被默认初始化:

derived(parms) : base(args) { }

和普通成员的using声明不一样,一个构造函数的using声明不会改变该构造函数的访问级别。

一个using声明语句不能指定explicit或constexpr,如果基类的构造函数是explicit或者constexpr,则继承的构造函数也拥有相同的属性。

当一个基类构造函数含有默认实参时,这些实参并不会被继承,相反,派生类将获得多个继承的构造函数,其中每个函数分别省略掉一个含有默认实参的形参:

//基类接受两个形参,一个有默认实参
Base(int a, int b=1){}

//继承的第一个构造函数:接受两个形参,没有默认实参
Derived(int a, int b) : Base(int a, int b){}
//继承的第二个构造函数:接受一个形参,对应没有默认值的那个形参
Derived(int a) : Base(int a){}

没看懂这个

如果基类含有几个构造函数,大多数派生类会继承所有这些构造函数,除了两个例外:

  • 派生类可以继承一部分构造函数,而为其他构造函数定义自己的版本。如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数将不会被继承。定义在派生类中的构造函数将替换继承而来的构造函数
  • 默认、拷贝和移动构造函数不会被继承,这些构造函数安装正常规则被合成。继承的构造函数不会被用作用户定义的构造函数来使用,因此,如果一个类只含有继承的构造函数,则它也将永远一个合成的默认构造函数

15.8 容器与继承

当使用容器存放继承体系中的对象时,通常必须采取间接存储的方式,因为不允许在容器中保存不同类型的原书。当派生类对象被赋值给基类对象时,其中派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。

当希望在容器中存放具有继承关系的对象时,实际上存放的通常是基类的(智能)指针。

就像可以把一个派生类的普通指针转换成基类指针一样,也可以把派生类的智能指针转换成基类的智能指针。